本文并非从专业开发的角度去阐述托管/非托管的概念及托管代码如何调用非托管代码,而是从日常的工具编写中及使用中遇到的一些问题,带着解决问题的态度出发,去看待这么一个过程。
整个过程并非专业解析,而只是助于我们理解罢了。
0x00 前言
托管/非托管是微软的 .NET Framework 中特有的概念,其中,非托管代码也叫本地(Native)代码。与 Java 中的机制类似,也是先将源代码编译成中间代码(MSIL,Microsoft Intermediate Language),然后再由 .NET 中的 CLR 将中间代码编译成机器代码。
在 Csharp 中,托管代码引用非托管代码的方式一般有两种:
- P/Invoke(平台调用)
- Delegate(委托)-> 后续转换为 D/Invoke(动态调用)
而个人在日常工具编写的过程中,经常用到的调用方式是 P/Invoke
。这种方式普遍应用于各大工具开发中,对于这种方式,从攻击角度来看,存在一些缺陷:
- 通过
P/Invoke
进行的任何 Windows API 引用都将在 .NET 程序集的 “导入表” 中产生一个相应的条目; - 在存在任何可监视 API 调用(如通过 API Hooking)的安全产品,都会在
P/Invoke
调用任何 API 上看到警告/阻止,这个 Hook 方式称之为 IAT hooking。
而动态调用的目的是提供一种访问(调用)这些 Windows API 的替代方案,而不会留下这些特定的指标(并不是说动态调用没有自身的指标)。
但是关于 Delegate
的使用,我们在大多数利用工具的开发中,很少人会去用到。但是如果去搜索这东西,会发现很早就有人使用它来写了东西,因此我们可以很快的找到资料进行学习。Delegate
主要用于解决 Csharp 和 DLL 之间的数据传送问题:
1 | 在这种混合编程中,Csharp 和 DLL 之间如何进行数据传送?这个问题起始很复杂,像 int,double 这种基本的数据类型,是很好传递的。到了 byte 和 char,就有点复杂了,更复杂的还有 string 和 stringBuilder,以及结构体的传递等。 |
若传递的是函数指针,有两种方法:
由于 Csharp 中没有函数指针的概念,因此采用委托(
Delegate
)的方式,使用 Intptr 存储指针,并使用 ref 获得地址(&);另一种是在 Csharp 中编写非托管的代码,用 unsafe 声明:
1
2
3
4
5unsafe
{
// 非托管代码
// 在非托管代码中,即可进行指针相关的操作。
}
因此本文会对 P/Invoke
,Delegate
两种调用方式进行一些说明,并说明动态调用为什么可以绕过 IAT hooking。
0x01 IAT hooking
Hook 的概念就不累述了
即使是基于 CS 的 execute-assembly
等内存执行方法,EDR 通过 Hook 进程,也能捕获到进攻性行为。针对这种情况,@CCob 巨佬在他的文章中也给了一个非常奈斯的例子,证明的这个 POC,以及如何绕过这种 Hooking。一个高效的 EDR,会尽可能的 Hooking 底层函数,如 NT 级别的 Win32 API。下图是一个很好的例子,充分阐明了 EDR 的工作原理(其中 ntdll.dll 负责向 Windows 内核进行系统调用):
EDR 的 Hook 方式主要有两种:
- IAT hooking:IAT 是
Import Address Table
的缩写。每个可执行程序都拥有该 IAT 区域,程序运行时,PE 装载器会将 Win32 API 的函数地址记录到 IAT 区域,在 EDR 的 hook.dll 注入到程序后,当程序调用到记录在内的函数时,则跳转至 hook.dll(至于是什么函数才跳转,由 EDR 决定)。 - Inline hooking:是一种通过修改机器码的方式来实现 Hook 的技术。
我们这就讲讲 IAT hooking
就好。关于 IAT hooking,可一看看下面的图:
在此示例中,就是简单的调用一个 MessageBoxA
的程序,该程序将会在 Import Address Table
中查找 MessageBoxA
的地址,以便它能够顺利的运行。
我们不知道的是, EDR 参与了其中,其实 EDR 替换了程序中的 IAT 区域内容。在程序调用 MessageBoxA
时,实际上该调用已经被 EDR 强制跳转到它自身 dll 的地址,因此最后是由 EDR 判断传递的数据是否是恶意的,还是正常的。如果是恶意代码,则拦截执行,反之。
从进攻角度来看,我们可以利用系统调用来绕过这些 Hook 方法,比较有参考的例子有:
- MDSec 的 Firewalker
- @CCob 的 Sharpblock
0x02 Platform Invocation
Csharp 能够像 C/C++ 一样可以调用 Win32 API 函数,大部分调用的方式是:
1 | using System; |
在 .NET 中,这个调用过程被称为 Platform Invoking,简称 P/invoke
。该机制允许 .NET 应用程序方位非托管库(DLL)中的数据和 API。通过使用 P/invoke
,Csharp 开发人员可以轻松地调用标准 Win32 API。
该过程主要是利用 System.Runtime.InteropServices
命名空间来完成,且由 CLR 管理。下图显示了 P/invoke
中非托管代码与托管代码之间的联系及过程:
当 P/invoke
调用非托管函数时,它将执行以下操作序列:
- 找到包含函数的 DLL;
- 将 DLL 加载到内存中;
- 在内存中找到该函数的地址,并将其参数压入堆栈,根据需要封送数据;
- 将控制权转移到非托管功能。
P/invoke
会将由非托管函数生成的异常抛出给托管调用方。
但是,利用 .NET 也存在操作上的缺点(第一小节中已经说明)。由于是 CLR 负责将 .NET 翻译成机器代码(语言),而可执行文件并没有直接翻译成这种代码。因此,可执行文件将整个代码库存储它的汇编代码中,因此稍微逆向该可执行文件,就可以看到全部信息。比如以下的一些信息:
0x03 Delegate
现在好多的工具都开始以动态调用/执行的方式进行编写,这是非常有趣的一点,也是非常值得我们去学习。D/invoke
允许我们调用 P/invoke
所使用的 API,但它不是静态导入,而是动态导入。这样子就不会将 Win32 API 地址写入 Import Address Table
中,这就意味着我们完全的绕过了 IAT hooking
。所以如果程序是使用了动态调用,我们是无法查看程序的导出表的。
那么,我们怎么实现动态调用呢?与其使用 P/Invoke 导入我们想调用的 API,不如将 DLL 手动加载到内存中。此后,我们会得到一个指向该 DLL 中的一个函数的指针,后续可以在传参的同时从指针中调用该函数。
那么说到指针,不得不说 C# 中的 Delegate(委托)了,该部分内容在第一小节中有讲到。因此我们直接看看具体是怎么实现的。
我们的目的是在内存中调用非托管代码。
可以通过 Delegate
的来实现这一点。.NET 包含了 Delegate API,作为在类中包装方法/函数的一种方式。如果你们曾经使用反射来枚举类中的方法,那么你可以自己观察一下,实际上就是一种 Delegate 的形式。
Delegate API 有很多奇妙的功能,比如可以从一个函数的指针实例化一个 Delegate,并在传递参数的同时动态调用该函数。这里主要用的函数是:GetDelegateForFunctionPointer
该函数原型为:
1 | public static Delegate GetDelegateForFunctionPointer (IntPtr ptr, Type t); |
需要两个参数,分别为:
- IntPtr ptr:要转换的非托管函数指针。
- Type t:要返回的委托人的类型,也就是要传入的非托管代码的函数原型。
当看到 Type t
这个类型参数时,可能会不理解。这其实就是操作者传入要调用的非托管代码的函数原型的地方。这可以让 Delegate 知道当它调用函数时如何设置 CPU 寄存器和栈。
如果你记得在 P/Invoke
中,肯定用过类似这样的方式来设置函数:
1 | [ ] |
定义一个委托的方式与此类似,可以像定义变量一样定义一个委托。同时还要指定由委托人封装的函数时使用的调用约定(C++的标准调用约定是 StdCall),此处的调用约定务必一致,要不然会出现堆栈被破坏的情况。
1 | [ ] |
一个函数原型就定义完成。
接下来就看看怎么获取函数的指针了。
如果了解一些 PE 结构,可以知道由于所有的程序在初始化运行时,本身都会加载一些模块(库),这些模块是保证程序能正常运行的基本要素。因此可直接在当前进程中查找所需模块,即可获取到基址。实现如下:
在获取模块基址之后,通过遍历模块导出表来解析函数的地址,具体实现,可以在 4.2 章节看到。这里还有一个要注意的问题,那就是如果程序在初始化时,所调用的库并没有在预加载的模块里面,那么上面的代码就不会返回结果。这种情况就需要从磁盘中查找所需 DLL。
基础条件已经满足,因此直接套用即可,这部分内容,TheWover 已经写好了库。
TheWover 写了一篇关于为什么使用 D/invoke 而非 P/invlke 的原因的文章,强烈推荐。并且他还发布了一个 NuGet 包,该库其实就是一个 Delegate 库和函数包装器,目前基本满足平时的工具开发需求,它实现了定义结构及功能,只需要引用即可。
0x04 示例
上面说了 TheWover 发布了他的 DInvoke 项目,这个项目主要是帮我们定义处理对应的结构及功能,否则需要自己去定义对应的结构。如果不想使用这个项目,那么自己手动构造即可,这个后续会说到。
4.1、使用 DInvoke
这里直接使用官方的示例。下面的例子演示了如何使用 DInvoke 动态查找和调用一个 DLL 的函数地址:
获取 ntdll.dll 的基址。当它被初始化时,它被加载到每个 Windows 进程中,所以我们知道它已经被加载了。因此,我们可以搜索 PEB 的加载模块列- 表来找到它的引用。一旦我们从 PEB 中找到了它的基址,我们就输出地址;
给定一个函数名称,使用 GetLibraryAddress 在 ntdll.dll 中找到一它的地址;
给定一个函数的序号,使用 GetLibraryAddress 在 ntdll.dll 中找到一它的地址;
给定一个函数的 HMACMD5 值,使用 GetLibraryAddress 在 ntdll.dll 中找到一它的地址;
在获取 ntdll.dll 的前提下,使用 GetExportAddress 在内存中按给定的函数名查找地址。
再看看官方的另一个例子。在下面的示例中,我们先使用 P/Invoke 调用 OpenProcess。然后,我们再使用 D/Invoke 方式调用它(多种),并证明任何一种动态调用的机制都成功执行了非托管代码且绕过了 API hooking。
1 | ///Author: TheWover |
为了更好的说明实验结果,我们使用 API Monitor v2
比作 EDR,并钩住 kernel32.dll!OpenProcess
,然后通过 API Monitor 运行该示例程序。接下来仔细观察那些用 PROCESS_ALL_ACCESS
Flag 的调用,然后根据基址进行校对。结果如下图所示:
结果很明显,P/Invoke 的方式可以成功捕获,但使用 D/Invoke、手动映射和模块重载映射时,未成功捕获。
4.2、手动构造
为了更好的理解动态调用,我们可以尝试手动进行构造,这里我们使用 【知识回顾】进程注入-第一部分 中的代码注入代码,由 P/Invoke
的方式转由 D/Invoke
调用。
1 | public class DELEGATES |
关键实现类 DemoDInvoke
:
1 | public class DemoDInvoke |
主类:
1 | public class Program |
最后补上一些 STRUCTS
,代码就完整了。
编译代码后,同上使用 API Monitor v2
比做 EDR,并且钩完所涉及的 API,它们分别是:
- kernel32.dll!OpenProcess
- kernel32.dll!VirtualAllocEx
- kernel32.dll!WriteProcessMemory
- kernel32.dll!CreateRemoteThread
- kernel32.dll!CloseHandle
效果如下:
完全没有被钩住。这就完全绕过了 IAT hooking。这里仅是绕过了 IAT hooking,在实战中,还需要处理 shellcode。
查看导出表情况:
后续随着 DInvoke 的完善,直接应用该库即可。这样无需自己定义函数,省时省力。
0x05 参考
- [1] 【Marshal.GetDelegateForFunctionPointer】
- [2] 【Offensive P/Invoke: Leveraging the Win32 API from Managed Code】
- [3] 【Dynamic Invocation in .NET to bypass hooks】
- [4] 【手游外挂基础篇之inline-hook】
- [5] 【Emulating Covert Operations - Dynamic Invocation (Avoiding PInvoke & API Hooks)】
- [6] 【Process Injection using DInvoke】